Skip to content

docs: Added documentation for new reactive future call functionality.#447

Open
FXschwartz wants to merge 2 commits into
serverpod:mainfrom
FXschwartz:reactive-future-calls
Open

docs: Added documentation for new reactive future call functionality.#447
FXschwartz wants to merge 2 commits into
serverpod:mainfrom
FXschwartz:reactive-future-calls

Conversation

@FXschwartz

Copy link
Copy Markdown
Contributor

Here is the documentation covering new reactive future call feature serverpod/serverpod#4882

Copilot AI review requested due to automatic review settings March 24, 2026 13:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new documentation page describing Serverpod’s new “reactive future calls” feature, explaining how to define handlers that react to database table changes and how the underlying outbox/trigger flow works.

Changes:

  • Introduces a new scheduling concepts doc page for reactive future calls.
  • Documents where filtering, hasChanged() usage, batching semantics, and runtime trigger management.
  • Covers operational behavior (polling/scan interval), transaction guarantees, and migration requirements (outbox table).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/06-concepts/14-scheduling/06-reactive-future-calls.md Outdated
Comment thread docs/06-concepts/14-scheduling/06-reactive-future-calls.md Outdated
Comment on lines +113 to +119
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside session.db.transaction, the example inserts a row without passing the transaction object. In the transactions docs, DB ops must receive transaction: transaction to actually be part of the transaction; otherwise this example may commit independently and would trigger the reactive call, contradicting the comment. Update the insert to include the transaction parameter.

Suggested change
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
await Trip.db.insertRow(
session,
Trip(status: 'Confirmed'),
transaction: transaction,
);
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(
session,
Trip(status: 'Confirmed'),
transaction: transaction,
);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we really need this change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fact-checking this, I find the suggestion by Copilot to be correct.

Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.

You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.

Comment on lines +113 to +119
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue in the committed-transaction example: Trip.db.insertRow(...) should receive transaction: transaction to ensure the insert is performed within the transaction being committed (consistent with the Transactions docs) and to make the example behavior correct.

Suggested change
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction);
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this change either

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we should apply it.

5. Processed entries are deleted from the outbox.

:::info
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this names futureCall.scanInterval but doesn't link to the configuration page that documents it. A reader who wants to actually set the value has to navigate the sidebar manually.

Suggested change
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration.
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. See [Configuration](configuration) for the full set of future-call options.

@Swiftaxe Swiftaxe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments added. Mainly about leaving out implementation details and skipping to what is essential for the user to know.

@@ -0,0 +1,131 @@
# Reactive Future Calls

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have started adding frontmatter description to all pages for SEO.
Also, use sentence case for headings.

Suggested change
# Reactive Future Calls
---
description: React to database row changes in near real-time using Serverpod's reactive future calls, which trigger your handler when inserts, updates, or deletes match a condition.
---
# Reactive future calls

After creating your reactive future call class, run code generation:

```bash
$ serverpod generate

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will update this to match the new serverpod start workflow in a different PR.

Comment on lines +113 to +119
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fact-checking this, I find the suggestion by Copilot to be correct.

Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.

You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.

Comment on lines +113 to +119
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we should apply it.


Reactive future calls let you react to database changes in near real-time. When a row is inserted, updated, or deleted, Serverpod can automatically and asynchronously invoke your code with the changed data. This is useful for scenarios like sending notifications when an order is confirmed, syncing data to external systems, or triggering workflows based on state changes.

Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The users don't need to know how things work under the hood. What they need to know here is that the rolled-back changes never produce events. We can skip directly to the point.

Suggested change
Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events.
Because the trigger runs in the same transaction, rolled-back changes never produce events.


Your reactive future calls are automatically discovered and registered alongside regular future calls. No manual registration is needed.

## How it works

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many technical details here that the user probably does not need to concern themselves with.

Consider just saying something like for the whole ## How it works section:

How it works

Reactive future calls use a scan interval. The same interval as regular future calls, configured via futureCall.scanInterval. Serverpod polls for new entries at this interval, so handlers fire in near real-time, rather than instantly.

Rows that accumulate between scans are batched together and delivered to react in a single call. This is why react receives a List rather than a single object.

}
```

The `where` getter defines a condition that becomes a `WHEN` clause on the PostgreSQL trigger. Only changes matching this condition will create outbox entries. In the example above, the trigger only fires when the `status` column equals `'Confirmed'`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioning 'outbox' is an implementation details of no concern to our users.

Could we boil these two paragraphs down to the essentials? Perhaps something like:

The react method is your handler. It's called with every row that matches the condition. The where getter filters which changes reach the handler.


## Transaction safety

Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this section talks about transaction commit/rollback semantics without linking to the transactions docs. A reader unfamiliar with session.db.transaction has to find it themselves.

Suggested change
Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees:
Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees (see [Transactions](../database/transactions) for the underlying API):

Comment on lines +111 to +120
// This will NOT trigger react — the transaction is rolled back
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: em-dashes in code comments. The style guide discourages them in running text, and comments read as prose to the reader. Swap for periods or colons.

Suggested change
// This will NOT trigger react — the transaction is rolled back
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
});
// This will NOT trigger react. The transaction is rolled back.
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react. The transaction is committed.
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
});

@developerjamiu developerjamiu added the documentation Improvements or additions to documentation label Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants